Repository Public
1import Link from "next/link";2import { notFound } from "next/navigation";3import { AutoRefresh } from "@/components/studio/AutoRefresh";4import { Meter, Notice, Panel, Status, ago, usd, when } from "@/components/studio/ui";5import { can, requireRole } from "@/lib/auth";6import { getPolicy, observedVideoRate } from "@/lib/budget";7import { CreativeBrief, critiqueScore, type Bible, type Critique, type Pitch, type TechnicalCheck } from "@/lib/contracts";8import { sql } from "@/lib/db";9import { env } from "@/lib/env";10import { storage } from "@/lib/storage";11import { NEXT, REQUIRES } from "@/pipeline/orchestrator";12import { STAGES, buildTimeline, currentScript, getShots, latestFilmVersion, shotDuration, type ProjectRow, type Stage } from "@/pipeline/stages";13import { estimateVideo } from "@/providers/pricing";14import { approveFilm, archiveProject, moveShot, publishFilm, regenerateShot, rejectFilm, runStage, selectPitch, setMode, updateShot } from "../../../actions";1516export const metadata = { title: "Project" };17type AssetRef = { id: string; sha256: string; ext: string };18const STAGE_LABEL: Record<Stage, [string, string]> = {19 concept: ["Concept", "Creative Director + critic: four scored pitches"], script: ["Script", "Screenwriter + Research Editor"], storyboard: ["Storyboard", "Bible, shots, image and motion prompts"],20 keyframes: ["Keyframes", "First frame of each shot"], voice: ["Voice", "Narration with word timings"], video: ["Video", "Image-to-video for each shot"],21 assemble: ["Assembly", "Cut, mix, captions, renditions"], qa: ["QA", "Technical checks + critic"],22};23const ORDER = ["draft", "pitched", "scripted", "storyboarded", "keyframes", "voiced", "filmed", "assembled", "qa"];24const rank = (status: string) => (["qa_passed", "qa_failed", "approved", "published"].includes(status) ? 8 : ORDER.indexOf(status));2526export default async function Project({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; err?: string; shot?: string }> }) {27 const { id } = await params;28 const sp = await searchParams;29 if (!/^[0-9a-f-]{36}$/.test(id)) notFound();30 const user = await requireRole("viewer");31 const editor = can(user, "editor"), owner = can(user, "owner");32 const [project] = await sql<(ProjectRow & { series_title: string | null })[]>`SELECT p.*, s.title_en AS series_title FROM projects p LEFT JOIN series s ON s.id = p.series_id WHERE p.id = ${id}`;33 if (!project) notFound();34 const brief = CreativeBrief.parse(project.brief);35 const [policy, candidates, sv, shots, fv, runs, jobs, costRow, film, approvals, storyboard] = await Promise.all([36 getPolicy(),37 sql<{ id: string; idx: number; pitch: Pitch; scores: Record<string, number | string | null>; total: number; selected: boolean }[]>`SELECT * FROM concept_candidates WHERE project_id = ${id} ORDER BY total DESC`,38 currentScript(id), getShots(id), latestFilmVersion(id),39 sql<{ id: string; stage: string; status: string; attempts: number; error: string | null; queued_at: Date; started_at: Date | null; finished_at: Date | null }[]>`SELECT * FROM stage_runs WHERE project_id = ${id} ORDER BY queued_at DESC LIMIT 12`,40 sql<{ id: string; shot_id: string | null; stage: string; kind: string; model: string; status: string; attempts: number; error: string | null; est_usd: number | null; cost_usd: number | null; cost_source: string | null; request_id: string | null; created_at: Date; finished_at: Date | null; asset_sha: string | null; asset_ext: string | null; settings: Record<string, unknown> }[]>`41 SELECT j.*, a.sha256 AS asset_sha, a.ext AS asset_ext FROM provider_jobs j LEFT JOIN assets a ON a.id = j.asset_id WHERE j.project_id = ${id} ORDER BY j.created_at DESC LIMIT 80`,42 sql<{ usd: number; failed: number }[]>`SELECT COALESCE(SUM(usd), 0)::float8 AS usd, COALESCE(SUM(usd) FILTER (WHERE failed), 0)::float8 AS failed FROM cost_entries WHERE project_id = ${id}`,43 sql<{ id: string; slug: string; status: string; published_at: Date | null; scheduled_for: Date | null; featured: boolean }[]>`SELECT id, slug, status, published_at, scheduled_for, featured FROM films WHERE project_id = ${id}`,44 sql<{ decision: string; note: string; at: Date; email: string | null }[]>`SELECT a.decision, a.note, a.at, u.email FROM approvals a LEFT JOIN users u ON u.id = a.user_id WHERE a.project_id = ${id} ORDER BY a.at DESC LIMIT 8`,45 sql<{ plan: { bible: Bible } }[]>`SELECT plan FROM storyboards WHERE project_id = ${id} ORDER BY version DESC LIMIT 1`,46 ]);47 const assetIds = [...shots.flatMap((s) => [s.image_asset_id, s.video_asset_id, s.audio_asset_id]), fv?.master_asset_id, fv?.poster_asset_id, fv?.manifest._assets.captions.en, fv?.manifest._assets.captions.fr].filter((x): x is string => !!x);48 const assets = assetIds.length ? await sql<AssetRef[]>`SELECT id, sha256, ext FROM assets WHERE id = ANY(${assetIds})` : [];49 const url = (assetId: string | null | undefined) => { const a = assets.find((x) => x.id === assetId); return a ? storage().getSignedUrl(a.sha256, a.ext, 6 * 3600) : null; };50 const signed = (sha: string | null, ext: string | null) => (sha && ext ? storage().getSignedUrl(sha, ext, 6 * 3600) : null);51 const [review] = fv ? await sql<{ technical: { checks: TechnicalCheck[]; reasons: string[] }; critique: Critique | null; score: number | null; passed: boolean; created_at: Date }[]>`SELECT * FROM quality_reviews WHERE film_version_id = ${fv.id} ORDER BY created_at DESC LIMIT 1` : [];52 const videoModel = project.video_model ?? env.videoModel;53 const rate = await observedVideoRate(videoModel, env.videoResolution);54 const pendingVideo = shots.filter((s) => !s.video_asset_id);55 const videoEst = pendingVideo.reduce((n, s) => n + (estimateVideo(videoModel, env.videoResolution, Math.ceil(shotDuration(s.narration_duration)), rate).usd ?? 0), 0);56 const videoBasis = estimateVideo(videoModel, env.videoResolution, 6, rate).basis;57 const tl = shots.length ? buildTimeline(shots) : null;58 const sel = shots.find((s) => s.id === sp.shot) ?? null;59 const versions = sel ? await sql<{ kind: string; version: number; prompt: string; author: string; created_at: Date }[]>`SELECT kind, version, prompt, author, created_at FROM prompt_versions WHERE shot_id = ${sel.id} ORDER BY kind, version DESC` : [];60 const busy = !!project.running_stage;61 const r = rank(project.status);62 const next = NEXT[project.status];63 const P = `/studio/projects/${id}`;64 const hidden = <input type="hidden" name="project" value={id} />;6566 return (67 <>68 <AutoRefresh seconds={4} active={busy} />69 <div className="mb-5">70 <Link href="/studio/projects" className="label hover:text-[var(--color-text)]">← Projects</Link>71 <div className="mt-3 flex flex-wrap items-start justify-between gap-4">72 <div className="min-w-0">73 <h1 className="display text-4xl md:text-5xl">{project.title}</h1>74 <p className="mt-2 flex flex-wrap items-center gap-2 text-sm text-[var(--color-faint)]">75 {busy ? <Status value="running" label={`running: ${project.running_stage}`} /> : <Status value={project.status} />}76 <span>{project.series_title ?? "Standalone"} · {project.language} · <span className="mono">{videoModel}</span> · {project.origin} · updated {ago(project.updated_at)}</span>77 </p>78 </div>79 <div className="w-full max-w-xs"><Meter label="Film budget" spent={costRow[0].usd} cap={Number(policy.per_film_usd)} />{costRow[0].failed > 0 && <p className="mt-1 text-xs text-[var(--color-bad)]">{usd(costRow[0].failed)} spent on failed generations</p>}</div>80 </div>81 </div>82 <Notice ok={sp.ok} err={sp.err} />83 {project.blocked_reason && <Notice err={`Blocked by the budget policy: ${project.blocked_reason}. Raise the ceiling in Settings or release the kill switch, then run the stage again.`} />}84 {project.error && !busy && <Notice err={`Last stage failed: ${project.error}`} />}8586 {/* Stage rail */}87 <Panel title="Pipeline" action={editor ? (88 <form action={setMode} className="flex items-center gap-2 text-xs">{hidden}<label htmlFor="mode" className="text-[var(--color-faint)]">Autonomy</label>89 <select id="mode" name="mode" defaultValue={project.mode} className="field !min-h-8 !w-auto !py-1 text-xs"><option value="manual">Manual</option><option value="assisted">Assisted</option><option value="autonomous">Autonomous with budget</option></select>90 <button className="btn btn-ghost btn-sm">Set</button></form>) : undefined}>91 <ol className="grid gap-2 sm:grid-cols-4 lg:grid-cols-8">92 {STAGES.map((stage, i) => {93 const done = r > i, current = next === stage, running = project.running_stage === stage;94 const allowed = REQUIRES[stage].includes(project.status);95 const last = runs.find((x) => x.stage === stage);96 return (97 <li key={stage} className={`rounded-xl border p-3 ${running ? "border-[var(--color-lamp)]" : current ? "border-[var(--color-line)] bg-[var(--color-raised)]" : "border-[var(--color-line-soft)]"}`}>98 <p className="flex items-center justify-between text-[0.68rem] uppercase tracking-[0.14em] text-[var(--color-faint)]"><span>{String(i + 1).padStart(2, "0")}</span><span className={running ? "text-[var(--color-accent)]" : done ? "text-[var(--color-ok)]" : last?.status === "failed" ? "text-[var(--color-bad)]" : last?.status === "blocked" ? "text-[var(--color-warn)]" : ""}>{running ? "running" : done ? "done" : last?.status === "failed" || last?.status === "blocked" ? last.status : current ? "next" : ""}</span></p>99 <p className="mt-1 font-medium">{STAGE_LABEL[stage][0]}</p>100 <p className="mt-0.5 min-h-8 text-[0.7rem] leading-snug text-[var(--color-faint)]">{STAGE_LABEL[stage][1]}</p>101 {stage === "video" && pendingVideo.length > 0 && shots.length > 0 && <p className="mt-1 text-[0.7rem] text-[var(--color-accent)]">≈ {usd(videoEst)} for {pendingVideo.length} shot{pendingVideo.length > 1 ? "s" : ""} ({videoBasis})</p>}102 {editor && allowed && !busy && (103 <form action={runStage} className="mt-2 flex gap-1">{hidden}<input type="hidden" name="stage" value={stage} />104 <button className={`btn btn-sm flex-1 ${current ? "btn-primary" : "btn-ghost"}`} disabled={policy.kill_switch}>{done ? "Re-run" : "Run"}</button>105 {done && ["concept", "script", "storyboard", "qa"].includes(stage) && <button name="fresh" value="1" className="btn btn-ghost btn-sm" title="Ask for a new answer instead of reusing the stored one" disabled={policy.kill_switch}>New</button>}106 </form>107 )}108 </li>109 );110 })}111 </ol>112 <p className="mt-3 text-xs text-[var(--color-faint)]">Re-running a stage is safe: finished provider calls are reused, so nothing is paid twice. “New” asks the model for a different answer. {project.mode === "assisted" && "Assisted mode pauses before Video."}</p>113 </Panel>114115 {/* Brief + pitches */}116 <div className="mt-5 grid gap-5 lg:grid-cols-[1fr_2fr]">117 <Panel title="Creative brief">118 <dl className="space-y-3 text-sm">119 {([["Theme", brief.theme], ["Objective", brief.objective], ["Audience", brief.audience], ["Mood", brief.mood], ["Genre", brief.genre], ["Constraints", brief.constraints], ["Shots", String(brief.shots)]] as const).filter(([, v]) => v).map(([k, v]) => <div key={k}><dt className="label">{k}</dt><dd className="mt-0.5 text-[var(--color-muted)]">{v}</dd></div>)}120 </dl>121 {editor && project.status !== "published" && !busy && <form action={archiveProject} className="mt-5">{hidden}<button className="text-xs text-[var(--color-faint)] underline">Archive this project</button></form>}122 </Panel>123 <Panel title={`Pitches (${candidates.length})`}>124 {candidates.length ? (125 <ul className="grid gap-3 md:grid-cols-2">126 {candidates.map((c) => (127 <li key={c.id} className={`rounded-xl border p-4 ${c.selected ? "border-[var(--color-lamp)] bg-[var(--color-raised)]" : "border-[var(--color-line-soft)]"}`}>128 <div className="flex items-start justify-between gap-3"><h3 className="display text-2xl">{c.pitch.title}</h3><span className="display text-3xl tabular-nums text-[var(--color-accent)]">{Number(c.total).toFixed(1)}</span></div>129 <p className="mt-2 text-sm text-[var(--color-muted)]">{c.pitch.logline}</p>130 <p className="mt-2 text-sm"><span className="label">Thesis </span>{c.pitch.thesis}</p>131 <details className="mt-2 text-xs text-[var(--color-muted)]"><summary className="cursor-pointer text-[var(--color-faint)]">Arc, world and risks</summary>132 <p className="mt-2"><b>Hook.</b> {c.pitch.hook}</p><p className="mt-1"><b>Arc.</b> {c.pitch.setup} → {c.pitch.tension} → {c.pitch.payoff}</p><p className="mt-1"><b>World.</b> {c.pitch.visualWorld}</p><p className="mt-1"><b>Artifact risks.</b> {c.pitch.artifactRisks}</p>133 </details>134 <p className="mt-3 text-[0.7rem] tabular-nums text-[var(--color-faint)]">originality {c.scores.originality} · feasibility {c.scores.feasibility} · visual {c.scores.visualPotential} · clarity {c.scores.narrativeClarity} · artifact risk {c.scores.artifactRisk}</p>135 {c.scores.note && <p className="mt-1 text-xs italic text-[var(--color-faint)]">{String(c.scores.note)}</p>}136 {c.scores.duplicateOf && <p className="mt-1 text-xs text-[var(--color-warn)]">Too close to “{String(c.scores.duplicateOf)}” (similarity {String(c.scores.similarity)}): −3 points.</p>}137 <div className="mt-3">{c.selected ? <Status value="approved" label="selected" /> : editor && !busy && r <= 3 && <form action={selectPitch}>{hidden}<input type="hidden" name="candidate" value={c.id} /><button className="btn btn-ghost btn-sm">Select this pitch</button></form>}</div>138 </li>139 ))}140 </ul>141 ) : <p className="text-sm text-[var(--color-faint)]">{busy ? "The Creative Director is writing pitches…" : "No pitch yet. Run the Concept stage."}</p>}142 </Panel>143 </div>144145 {/* Script + research */}146 {sv && (147 <div className="mt-5 grid gap-5 lg:grid-cols-[2fr_1fr]">148 <Panel title={`Script — version ${sv.version} (${sv.author})`}>149 <p className="display text-3xl">{sv.script.titleEn} <span className="text-[var(--color-faint)]">/ {sv.script.titleFr}</span></p>150 <p className="mt-2 text-sm text-[var(--color-muted)]">{sv.script.synopsisEn}</p>151 <p className="mt-1 text-sm text-[var(--color-faint)]" lang="fr">{sv.script.synopsisFr}</p>152 <p className="mt-3 text-sm"><span className="label">Takeaway </span>{sv.script.takeawayEn}</p>153 <p className="mt-1 text-xs text-[var(--color-faint)]">Narrator: preset voice “{sv.script.voiceId}” — {sv.script.voiceDirection}</p>154 <ol className="mt-4 space-y-2">155 {sv.script.beats.map((b, i) => <li key={i} className="grid grid-cols-[2rem_1fr] gap-2 text-sm"><span className="tabular-nums text-[var(--color-faint)]">{i + 1}</span><span><span className="mr-2 text-[0.65rem] uppercase tracking-[0.14em] text-[var(--color-accent)]">{b.purpose} · {b.lang}</span>{b.narration}<br /><span className="text-xs text-[var(--color-faint)]">{b.narrationAlt}</span></span></li>)}156 </ol>157 <p className="mt-3 text-xs text-[var(--color-faint)]">Edit individual lines in the shot inspector below (each edit is a new version).</p>158 </Panel>159 <Panel title="Research Editor">160 {!sv.research ? <p className="text-sm text-[var(--color-muted)]">Fiction: the script makes no factual claim, so nothing needs checking.</p> : sv.research.claims?.length ? (161 <ul className="space-y-3 text-sm">162 {sv.research.claims.map((c, i) => (163 <li key={i}><Status value={c.verdict === "supported" ? "done" : c.verdict === "inaccurate" ? "failed" : "blocked"} label={c.verdict} /><p className="mt-1">{c.claim}</p>{c.correction && <p className="mt-1 text-xs text-[var(--color-warn)]">Correction: {c.correction}</p>}164 <ul className="mt-1 space-y-0.5 text-xs">{c.sources.map((s) => <li key={s.url}><a href={s.url} target="_blank" rel="noopener noreferrer" className="break-all text-[var(--color-muted)] underline">{s.title || s.url}</a></li>)}</ul></li>165 ))}166 </ul>167 ) : <p className="text-sm text-[var(--color-muted)]">{sv.research.note ?? "No factual claim declared."}</p>}168 {sv.research?.checkedAt && <p className="mt-3 text-xs text-[var(--color-faint)]">Checked {when(sv.research.checkedAt)} with web search. {sv.research.unresolved ? `${sv.research.unresolved} claim(s) unresolved: QA will hold the film.` : "All claims supported."}</p>}169 </Panel>170 </div>171 )}172173 {/* Storyboard */}174 {shots.length > 0 && (175 <div className="mt-5">176 <Panel title={`Storyboard — ${shots.length} shots`}>177 {storyboard[0] && (178 <details className="mb-4 text-sm text-[var(--color-muted)]"><summary className="cursor-pointer text-[var(--color-faint)]">Style and character bible</summary>179 <dl className="mt-3 grid gap-3 sm:grid-cols-2">{(["palette", "lighting", "lens", "world", "avoid"] as const).map((k) => <div key={k}><dt className="label">{k}</dt><dd className="mt-0.5">{storyboard[0].plan.bible[k]}</dd></div>)}180 <div><dt className="label">Continuity anchors</dt><dd className="mt-0.5">{storyboard[0].plan.bible.anchors.join(" · ")}</dd></div>181 {storyboard[0].plan.bible.characters.length > 0 && <div className="sm:col-span-2"><dt className="label">Characters</dt><dd className="mt-0.5">{storyboard[0].plan.bible.characters.map((c) => `${c.name}: ${c.description}`).join(" — ")}</dd></div>}</dl>182 </details>183 )}184 <ul className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">185 {shots.map((s) => {186 const img = url(s.image_asset_id), vid = url(s.video_asset_id);187 return (188 <li key={s.id} id={`shot-${s.id}`} className={`rounded-xl border p-2 ${sel?.id === s.id ? "border-[var(--color-lamp)]" : "border-[var(--color-line-soft)]"}`}>189 <div className="relative aspect-[9/16] overflow-hidden rounded-lg bg-black">190 {vid ? <video src={vid} poster={img ?? undefined} controls preload="none" playsInline className="h-full w-full object-cover" aria-label={`Shot ${s.idx + 1} clip`} /> : img ? <img src={img} alt={`Keyframe of shot ${s.idx + 1}`} className="h-full w-full object-cover" loading="lazy" /> : <div className="grid h-full place-items-center text-xs text-[var(--color-faint)]">no keyframe</div>}191 <span className="absolute left-1.5 top-1.5 rounded bg-black/70 px-1.5 text-xs tabular-nums">{s.idx + 1}</span>192 </div>193 <p className="mt-2 truncate text-sm font-medium">{s.title || `Shot ${s.idx + 1}`}</p>194 <p className="mt-0.5 line-clamp-2 min-h-8 text-xs text-[var(--color-muted)]">{s.narration}</p>195 <p className="mt-1 flex flex-wrap items-center gap-1 text-[0.68rem] text-[var(--color-faint)]">196 <Status value={s.error ? "failed" : s.video_asset_id ? "filmed" : s.status} label={s.error ? "error" : s.video_asset_id ? "clip" : s.audio_asset_id && s.image_asset_id ? "ready" : s.image_asset_id ? "keyframe" : "planned"} />197 {s.narration_duration !== null && <span className="tabular-nums">{shotDuration(s.narration_duration).toFixed(1)} s</span>}{s.regen_count > 0 && <span>· {s.regen_count} regen</span>}198 </p>199 {s.error && <p className="mt-1 break-words text-[0.68rem] text-[var(--color-bad)]">{s.error}</p>}200 <Link href={`${P}?shot=${s.id}#inspector`} scroll={false} className="btn btn-ghost btn-sm mt-2 w-full">Inspect</Link>201 </li>202 );203 })}204 </ul>205206 {tl && shots.some((s) => s.narration_duration !== null) && (207 <div className="mt-5">208 <p className="label mb-2">Timeline — {tl.total.toFixed(1)} s</p>209 <div className="flex h-12 gap-0.5 overflow-hidden rounded-lg" role="img" aria-label={`Timeline of ${shots.length} shots, ${tl.total.toFixed(1)} seconds`}>210 {tl.shots.map((t, i) => (211 <Link key={i} href={`${P}?shot=${shots[i].id}#inspector`} scroll={false} style={{ width: `${(t.duration / tl.total) * 100}%` }} className="relative bg-[var(--color-raised)] hover:bg-[var(--color-line)]" title={`Shot ${i + 1}: ${t.start.toFixed(1)}–${(t.start + t.duration).toFixed(1)} s${t.transition === "fade" ? " (fade in)" : ""}`}>212 <span className="absolute inset-y-2 rounded-sm bg-[color-mix(in_srgb,var(--color-lamp)_55%,transparent)]" style={{ left: `${((t.narrationStart - t.start) / t.duration) * 100}%`, width: `${((t.narrationEnd - t.narrationStart) / t.duration) * 100}%` }} />213 <span className="absolute left-1 top-0.5 text-[0.62rem] tabular-nums text-[var(--color-text)]">{i + 1}{t.transition === "fade" ? " ◐" : ""}</span>214 </Link>215 ))}216 </div>217 <p className="mt-1 text-xs text-[var(--color-faint)]">Amber = narration. Each shot lasts as long as its line needs, plus a short lead and tail. ◐ = fade.</p>218 </div>219 )}220 </Panel>221 </div>222 )}223224 {/* Shot inspector / Prompt Workbench */}225 {sel && (226 <div className="mt-5" id="inspector">227 <Panel title={`Shot ${sel.idx + 1} — inspector and prompt workbench`} action={<Link href={P} scroll={false} className="text-xs text-[var(--color-faint)] underline">Close</Link>}>228 <div className="grid gap-5 lg:grid-cols-[220px_1fr_1fr]">229 <div>230 <div className="aspect-[9/16] overflow-hidden rounded-lg bg-black">{url(sel.video_asset_id) ? <video src={url(sel.video_asset_id)!} poster={url(sel.image_asset_id) ?? undefined} controls playsInline className="h-full w-full object-cover" /> : url(sel.image_asset_id) ? <img src={url(sel.image_asset_id)!} alt="Keyframe" className="h-full w-full object-cover" /> : null}</div>231 {url(sel.audio_asset_id) && <audio src={url(sel.audio_asset_id)!} controls preload="none" className="mt-2 w-full" aria-label="Narration" />}232 <dl className="mt-3 space-y-1 text-xs text-[var(--color-muted)]"><div><dt className="inline text-[var(--color-faint)]">Camera: </dt><dd className="inline">{sel.camera}</dd></div><div><dt className="inline text-[var(--color-faint)]">Action: </dt><dd className="inline">{sel.action}</dd></div><div><dt className="inline text-[var(--color-faint)]">Continuity: </dt><dd className="inline">{sel.continuity}</dd></div><div><dt className="inline text-[var(--color-faint)]">Transition in: </dt><dd className="inline">{sel.transition}</dd></div></dl>233 {editor && !busy && project.status !== "published" && (234 <div className="mt-3 space-y-2">235 <div className="flex gap-2">{([-1, 1] as const).map((d) => <form key={d} action={moveShot} className="flex-1">{hidden}<input type="hidden" name="shot" value={sel.id} /><input type="hidden" name="dir" value={d} /><button className="btn btn-ghost btn-sm w-full" disabled={(d === -1 && sel.idx === 0) || (d === 1 && sel.idx === shots.length - 1)}>{d === -1 ? "← Earlier" : "Later →"}</button></form>)}</div>236 {(["image", "voice", "video"] as const).map((what) => {237 const needs = what !== "voice" && sel.regen_count >= policy.max_regenerations && !sel.regen_approved;238 return (239 <form key={what} action={regenerateShot}>{hidden}<input type="hidden" name="shot" value={sel.id} /><input type="hidden" name="what" value={what} />240 {needs && owner && <input type="hidden" name="approve" value="1" />}241 <button className={`btn btn-sm w-full ${needs ? "btn-danger" : "btn-ghost"}`} disabled={policy.kill_switch || (needs && !owner)}>{needs ? (owner ? `Approve + regenerate ${what}` : `Regenerate ${what} (owner approval needed)`) : what === "image" ? "Regenerate keyframe" : what === "voice" ? "Replace voice take" : "Regenerate clip"}</button>242 </form>243 );244 })}245 <p className="text-[0.68rem] text-[var(--color-faint)]">{sel.regen_count} of {policy.max_regenerations} regenerations used. Beyond the limit an owner must approve.</p>246 </div>247 )}248 </div>249 <form action={updateShot} className="space-y-3">{hidden}<input type="hidden" name="shot" value={sel.id} />250 <div><label htmlFor="narration" className="label">Narration (spoken, {sel.timestamps?.lang ?? "en"})</label><textarea id="narration" name="narration" rows={3} defaultValue={sel.narration} maxLength={400} className="field mt-1" readOnly={!editor} /></div>251 <div><label htmlFor="narrationAlt" className="label">Caption adaptation (other language)</label><textarea id="narrationAlt" name="narrationAlt" rows={2} defaultValue={sel.narration_alt} maxLength={400} className="field mt-1" readOnly={!editor} /></div>252 <div><label htmlFor="imagePrompt" className="label">Image prompt (first frame)</label><textarea id="imagePrompt" name="imagePrompt" rows={7} defaultValue={sel.image_prompt} maxLength={4000} className="field mt-1 text-xs" readOnly={!editor} /></div>253 <div><label htmlFor="videoPrompt" className="label">Motion prompt (image-to-video)</label><textarea id="videoPrompt" name="videoPrompt" rows={5} defaultValue={sel.video_prompt} maxLength={4000} className="field mt-1 text-xs" readOnly={!editor} /></div>254 {editor && <button className="btn btn-primary" disabled={busy || project.status === "published"}>Save as new version</button>}255 <p className="text-xs text-[var(--color-faint)]">Saving keeps every previous version. A changed prompt invalidates the asset that depended on it; a changed line also invalidates the clip, whose length follows the narration.</p>256 </form>257 <div>258 <p className="label">Variants generated for this shot</p>259 <ul className="mt-2 grid grid-cols-3 gap-2">260 {jobs.filter((x) => x.shot_id === sel.id && x.asset_sha && (x.kind === "image" || x.kind === "video")).map((x) => (261 <li key={x.id} className="text-[0.65rem] text-[var(--color-faint)]">262 <div className="aspect-[9/16] overflow-hidden rounded bg-black">{x.kind === "video" ? <video src={signed(x.asset_sha, x.asset_ext)!} controls preload="none" playsInline className="h-full w-full object-cover" /> : <img src={signed(x.asset_sha, x.asset_ext)!} alt="" className="h-full w-full object-cover" loading="lazy" />}</div>263 <p className="mt-1">{x.kind} · {usd(x.cost_usd, 3)}<br />{ago(x.created_at)}</p>264 </li>265 ))}266 </ul>267 <p className="label mt-4">Prompt history</p>268 <ul className="mt-2 max-h-80 space-y-2 overflow-y-auto pr-1 text-xs">269 {versions.map((v) => <li key={`${v.kind}${v.version}`} className="rounded-lg border border-[var(--color-line-soft)] p-2"><p className="text-[var(--color-faint)]">{v.kind} v{v.version} · {v.author} · {ago(v.created_at)}</p><p className="mt-1 whitespace-pre-wrap break-words text-[var(--color-muted)]">{v.prompt}</p></li>)}270 </ul>271 </div>272 </div>273 </Panel>274 </div>275 )}276277 {/* Preview, QA and release */}278 {fv && (279 <div className="mt-5 grid gap-5 lg:grid-cols-[300px_1fr]">280 <Panel title={`Cut v${fv.version} — ${Number(fv.duration).toFixed(1)} s`}>281 <div className="aspect-[9/16] overflow-hidden rounded-lg bg-black">282 <video src={url(fv.master_asset_id) ?? undefined} poster={url(fv.poster_asset_id) ?? undefined} controls playsInline crossOrigin="anonymous" className="h-full w-full object-contain" aria-label="Film preview">283 {url(fv.manifest._assets.captions.en) && <track kind="captions" srcLang="en" label="English" src={url(fv.manifest._assets.captions.en)!} default />}284 {url(fv.manifest._assets.captions.fr) && <track kind="captions" srcLang="fr" label="Français" src={url(fv.manifest._assets.captions.fr)!} />}285 </video>286 </div>287 <p className="mt-2 text-xs text-[var(--color-faint)]">Assembled {when(fv.created_at)}. {r < 7 && "The shots changed since this cut: run Assembly again."}</p>288 </Panel>289 <div className="space-y-5">290 <Panel title="Automated QA">291 {review ? (292 <>293 <div className="flex flex-wrap items-center gap-3"><Status value={review.passed ? "qa_passed" : "qa_failed"} label={review.passed ? "passed" : "failed"} />{review.score !== null && <span className="display text-3xl tabular-nums">{Number(review.score).toFixed(1)}<span className="text-base text-[var(--color-faint)]"> / 10 · threshold {Number(policy.qa_threshold).toFixed(1)}</span></span>}<span className="text-xs text-[var(--color-faint)]">{when(review.created_at)}</span></div>294 {review.technical.reasons?.length > 0 && <ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-[var(--color-bad)]">{review.technical.reasons.map((x, i) => <li key={i}>{x}</li>)}</ul>}295 <div className="mt-4 grid gap-5 md:grid-cols-2">296 <div><p className="label mb-2">Technical checks</p><ul className="space-y-1.5 text-xs">{review.technical.checks.map((c) => <li key={c.id} className="flex gap-2"><span aria-hidden="true" className={c.ok ? "text-[var(--color-ok)]" : c.severity === "fail" ? "text-[var(--color-bad)]" : "text-[var(--color-warn)]"}>{c.ok ? "✓" : c.severity === "fail" ? "✕" : "!"}</span><span><span className="sr-only">{c.ok ? "Passed: " : "Failed: "}</span>{c.label} <span className="text-[var(--color-faint)]">— {c.detail}</span></span></li>)}</ul></div>297 <div><p className="label mb-2">Critic rubric</p>{review.critique ? (298 <>299 <ul className="space-y-1.5 text-xs">{([["Narrative coherence", review.critique.narrativeCoherence], ["Visual fidelity", review.critique.visualFidelity], ["Continuity", review.critique.continuity], ["Audio intelligibility", review.critique.audioIntelligibility], ["Factual integrity", review.critique.factualIntegrity], ["Accessibility", review.critique.accessibility], ["Safety", review.critique.safety]] as const).map(([k, v]) => (300 <li key={k} className="grid grid-cols-[9.5rem_1fr_1.6rem] items-center gap-2"><span>{k}</span><span className="h-1.5 overflow-hidden rounded-full bg-[var(--color-line)]"><span className="block h-full rounded-full" style={{ width: `${v * 10}%`, background: v >= 7 ? "var(--color-ok)" : v >= 5 ? "var(--color-warn)" : "var(--color-bad)" }} /></span><span className="text-right tabular-nums">{v}</span></li>))}</ul>301 <p className="mt-3 text-xs text-[var(--color-muted)]">{review.critique.summary} <span className="text-[var(--color-faint)]">(mean {critiqueScore(review.critique)})</span></p>302 </>) : <p className="text-xs text-[var(--color-warn)]">The critic could not run.</p>}</div>303 </div>304 {review.critique && review.critique.issues.length > 0 && (305 <div className="mt-4"><p className="label mb-2">Issues and suggested fixes</p><ul className="space-y-2 text-xs">{review.critique.issues.map((x, i) => (306 <li key={i} className="rounded-lg border border-[var(--color-line-soft)] p-2"><Status value={x.severity === "blocking" ? "failed" : x.severity === "major" ? "blocked" : "queued"} label={`${x.severity}${x.shot ? ` · shot ${x.shot}` : ""}`} /><p className="mt-1">{x.note}</p><p className="mt-1 text-[var(--color-faint)]">Fix: {x.fix}</p>307 {x.shot > 0 && shots[x.shot - 1] && <Link href={`${P}?shot=${shots[x.shot - 1].id}#inspector`} scroll={false} className="mt-1 inline-block underline">Open shot {x.shot}</Link>}</li>))}</ul></div>308 )}309 </>310 ) : <p className="text-sm text-[var(--color-faint)]">QA has not run on this cut.</p>}311 </Panel>312313 <Panel title="Approval and release">314 {film[0] && <p className="mb-3 text-sm"><Status value={film[0].status} /> {film[0].status === "published" && <a href={`/film/${film[0].slug}`} className="ml-2 underline">/film/{film[0].slug}</a>}{film[0].status === "scheduled" && <span className="ml-2 text-[var(--color-muted)]">release {when(film[0].scheduled_for)}</span>}</p>}315 {!editor ? <p className="text-sm text-[var(--color-faint)]">Your role cannot approve or publish.</p> : busy ? <p className="text-sm text-[var(--color-faint)]">Wait for the running stage.</p> : (316 <div className="space-y-4">317 {project.status === "qa_passed" && <form action={approveFilm} className="flex flex-wrap items-end gap-3">{hidden}<div className="min-w-[16rem] flex-1"><label htmlFor="anote" className="label">Note (optional)</label><input id="anote" name="note" maxLength={300} className="field mt-1" /></div><button className="btn btn-primary">Approve for release</button></form>}318 {project.status === "qa_failed" && (319 <form action={approveFilm} className="space-y-2 rounded-xl border border-[color-mix(in_srgb,var(--color-bad)_45%,transparent)] p-3">{hidden}<input type="hidden" name="override" value="1" />320 <p className="text-sm text-[var(--color-bad)]">QA failed. This film cannot be published unless an owner overrides the result with a written reason, which is recorded.</p>321 <label htmlFor="onote" className="label">Reason for the override (required)</label><textarea id="onote" name="note" rows={2} minLength={10} required className="field" disabled={!owner} />322 <button className="btn btn-danger" disabled={!owner}>{owner ? "Override QA and approve" : "Owner only"}</button>323 </form>324 )}325 {["qa_passed", "qa_failed", "approved"].includes(project.status) && <form action={rejectFilm} className="flex flex-wrap items-end gap-3">{hidden}<div className="min-w-[16rem] flex-1"><label htmlFor="rnote" className="label">Reject with a note</label><input id="rnote" name="note" maxLength={300} className="field mt-1" /></div><button className="btn btn-ghost">Reject</button></form>}326 {["approved", "published"].includes(project.status) && (327 <form action={publishFilm} className="flex flex-wrap items-end gap-3">{hidden}328 <div><label htmlFor="sched" className="label">Schedule (optional, local time)</label><input id="sched" name="scheduleFor" type="datetime-local" className="field mt-1" /></div>329 <label className="flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" name="featured" value="1" /> Make it the homepage hero</label>330 <button className="btn btn-primary">{project.status === "published" ? "Republish this cut" : "Publish"}</button>331 </form>332 )}333 {!["qa_passed", "qa_failed", "approved", "published"].includes(project.status) && <p className="text-sm text-[var(--color-faint)]">Run QA on the current cut before approval.</p>}334 </div>335 )}336 {approvals.length > 0 && <ul className="mt-4 space-y-1 border-t border-[var(--color-line-soft)] pt-3 text-xs text-[var(--color-muted)]">{approvals.map((a, i) => <li key={i}>{when(a.at)} — <b>{a.decision.replace(/_/g, " ")}</b> by {a.email ?? "policy"}{a.note ? `: ${a.note}` : ""}</li>)}</ul>}337 </Panel>338 </div>339 </div>340 )}341342 {/* Jobs */}343 <div className="mt-5 grid gap-5 lg:grid-cols-[1fr_2fr]">344 <Panel title="Stage runs">345 {runs.length ? <ul className="space-y-2 text-xs">{runs.map((x) => <li key={x.id}><div className="flex items-center justify-between gap-2"><span>{x.stage} <span className="text-[var(--color-faint)]">· attempt {x.attempts || 1} · {ago(x.queued_at)}</span></span><Status value={x.status} /></div>{x.error && <p className="mt-0.5 break-words text-[var(--color-bad)]">{x.error}</p>}</li>)}</ul> : <p className="text-sm text-[var(--color-faint)]">No run yet.</p>}346 </Panel>347 <Panel title="Provider calls (provenance and cost)">348 {jobs.length ? (349 <div className="max-h-96 overflow-auto"><table className="studio-table"><thead><tr><th>When</th><th>Stage</th><th>Kind · model</th><th>State</th><th>Estimate</th><th>Actual</th></tr></thead><tbody>350 {jobs.map((x) => <tr key={x.id}><td className="whitespace-nowrap text-[var(--color-muted)]">{ago(x.created_at)}</td><td>{x.stage}{x.shot_id ? ` · shot ${(shots.find((s) => s.id === x.shot_id)?.idx ?? -1) + 1 || "?"}` : ""}</td><td><span className="mono">{x.kind} · {x.model}</span>{x.request_id && <span className="block text-[0.65rem] text-[var(--color-faint)]">request {x.request_id.slice(0, 13)}…</span>}</td>351 <td><Status value={x.status} label={`${x.status}${x.attempts > 1 ? ` ×${x.attempts}` : ""}`} />{x.error && <p className="mt-1 max-w-xs break-words text-[0.68rem] text-[var(--color-bad)]">{x.error}</p>}</td><td className="tabular-nums">{usd(x.est_usd, 3)}</td><td className="tabular-nums">{x.status === "done" || x.cost_usd !== null ? usd(x.cost_usd, 4) : "—"}{x.cost_source && <span className="block text-[0.65rem] text-[var(--color-faint)]">{x.cost_source === "ticks" ? "billed" : x.cost_source}</span>}</td></tr>)}352 </tbody></table></div>353 ) : <p className="text-sm text-[var(--color-faint)]">No provider call yet.</p>}354 </Panel>355 </div>356 </>357 );358}359